Skip to content

feat(provisioning): declarative realm provisioning (import / apply / prune / export + per-realm self-service)#107

Merged
windischb merged 21 commits into
developfrom
feat/realm-declarative-provisioning
Jun 30, 2026
Merged

feat(provisioning): declarative realm provisioning (import / apply / prune / export + per-realm self-service)#107
windischb merged 21 commits into
developfrom
feat/realm-declarative-provisioning

Conversation

@windischb

Copy link
Copy Markdown
Contributor

What

Declarative realm provisioning — stand up, update, or tear down a complete realm
(apps, OAuth APIs/scopes/clients, roles, users, groups, settings) from a single JSON
manifest, at runtime, by reusing the exact operations the admin UI already uses.
Realm-as-code — for reproducible setups, fast per-test realms, and agent automation.

Built on one governing invariant: exactly one canonical write path per mutation. The
applier reimplements nothing — each manifest entity is dispatched to the same application
operation as the manual admin path, so the two can never drift.

Two surfaces

Control-plane (operators) Per-realm self-service (delegated)
Path /api/admin/realms/* /api/admin/realm-config/*
Runs on Control-Plane host only (404 elsewhere) the realm's own host
Permission realm:write on the control-plane app realm:admin in that realm
Scope create / update / export / delete any realm update + export its own realm
Cannot create/delete realms; touch another realm

The per-realm surface lets an operator hand one realm to an app team or agent (a user
or a service account holding realm:admin in that realm) to fully manage that realm's
config + entities — without any control-plane power.

Endpoints

  • POST /api/admin/realms/import — create a new realm (all-or-nothing; rolls back via hard-delete on failure).
  • POST /api/admin/realms/{slug}/apply — in-place merge/upsert (never drops the DB). ?prune=true = full sync.
  • GET /api/admin/realms/{slug}/export — structure-only manifest (never secrets / password hashes).
  • GET /api/admin/realms/manifest-schema — the JSON Schema (generated from the live type, per-field descriptions + example).
  • DELETE /api/admin/realms/{slug}?hard=true — prod-safe hard-delete (drops the tenant database).
  • GET|POST /api/admin/realm-config/{manifest-schema,export,apply} — the per-realm (data-plane) surface, gated realm:admin.

Plus Modgud.Provisioning.TestKit — a standalone, zero-server-dep NuGet client
(ImportRealmAsyncProvisionedRealm with SecretFor / ApplyAsync / dispose→hard-delete).

Notable design points

  • Hard-delete drops the tenant DB at runtime (RemoveTenantAsyncDROP DATABASE … WITH (FORCE)),
    control-plane-guarded. The risk gate of the whole feature.
  • UpdateRealm is an in-place merge, never Remove+Import — dropping the DB would kill signing
    keys, the OpenIddict token store, and change every user's sub.
  • Prune protections (no lockout): never prunes the system app, auto-seeded standard scopes,
    service-account-linked clients, or any realm:admin path (realm-admin role, an admin user, or an
    admin-conferring group). Bounded to the realm.
  • Surgical apply: nullable OAuth bools (omit = no change), null-string / empty-list = no change,
    app-catalog ids preserved across updates; client secrets minted only at create.
  • Tenant-durability gotchas handled: groups + user-update + prune deletes run on a plain tenant
    session (not the Wolverine outbox), since their events have durable ReferenceSync forwarders.

Security

The authorization model was reviewed and is sound: the per-realm realm:admin gate is evaluated
against the current (host-routed) realm via the request-scoped, tenant-bound permission service;
auth cookies are encrypted with per-realm DataProtection keys (an X cookie can't decrypt on Y);
the control-plane surface is 404 on tenant hosts. apply pins the manifest to the current realm and
rejects a foreign slug. No new privilege (apply = the bulk form of what realm:admin already does in
its realm). The image is secure-by-default (Production env, fail-closed boot guards); the dev compose's
loose config is opt-in via env vars.

One documented, pre-existing follow-up (not introduced or worsened here): OAuth client delete
doesn't revoke the client's already-issued tokens — Atlas
engineering/oauth-client-delete-token-revocation has the full fix recipe.

Tests & docs

  • Cold-start integration tests for the applier (import / update / prune), the control-plane endpoints,
    the per-realm realm-config endpoints, export round-trip, the manifest schema, the TestKit, and a
    manifest↔admin-DTO parity guard; plus delete-op endpoint tests. Full suite green (1269 unit + 483 integration).
  • User-facing docs: docs/admin/realm-provisioning.md (two surfaces + delegation), reference table,
    admin index + realms cross-links. Design-of-record in dev-docs/future-features/.
  • A local app-testing stack under dev/app-testing/ (compose + recipe) for spinning up a throwaway
    Modgud per test run.

🤖 Generated with Claude Code

windischb and others added 21 commits June 30, 2026 06:20
… runtime

Adds IRealmProvisioningService.HardDeleteRealmAsync alongside the existing
reversible soft-delete. Sequence (verified by integration test): MasterTableTenancy
.RemoveTenantAsync (evict tenancy cache + dispose data source + delete the
realms.mt_tenant_databases registry row) -> DROP DATABASE ... WITH (FORCE) (terminates
the per-tenant async-daemon backend) -> remove the global Realm record + invalidate
the realm cache. Guarded against the control-plane realm.

RealmHardDeleteTests proves: the victim tenant DB is physically dropped, the global
record is gone, a sibling realm + its DB are fully intact, and the control-plane
realm is refused with its DB surviving.

Known caveat (documented in code + Atlas): re-creating a realm with the SAME slug in
the SAME process reuses Weasel's connection-string-cached NpgsqlDataSource (no per-key
eviction). Unique slugs avoid it; a custom evictable INpgsqlDataSourceFactory is the
clean fix if in-process slug reuse is ever needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… oauth + users)

The in-process engine behind declarative realm provisioning. Maps a RealmManifest
onto the EXISTING canonical operations — never reimplementing a mutation: realm shell
via IRealmProvisioningService, settings via IRealmSettingsService, OAuth apis/scopes/
clients via OAuthAdminService, users via the CreateUserCommand Wolverine handler.

Correctness:
- Tenant routing: the realm shell is created against the global store, then per-tenant
  config runs under TenantContext.Enter(slug) + a fresh DI scope. TenantedSessionFactory
  prefers the AsyncLocal TenantContext over the ambient (control-plane) HttpContext, so
  direct-service writes land in the NEW realm even though the call runs on the CP host.
- Wolverine commands resolve their Marten session from the message-envelope tenant, not
  TenantContext, so user creation uses InvokeForTenantAsync(slug, ...) (a plain
  InvokeAsync falls back to a tenant-less session and throws "Default tenant").
- All-or-nothing: any failure during apply hard-deletes the partially-provisioned realm.

RealmManifestApplierTests proves an import lands the oauth config + user in the new
realm's DB (verified via the canonical read methods), that it is isolated from the
system tenant, and that a duplicate slug is rejected.

v1 covers realm/settings/oauth/users; apps, roles, groups, login providers and the
UpdateRealm merge come next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Moves the App-create logic (slug/displayName/duplicate validation +
NormalizePermissions + StartStream) out of the AppsEndpoints MapPost lambda into a
shared AppAdminService returning ErrorOr<App>. The endpoint now delegates and maps
ErrorOr→IResult; the realm-provisioning applier will call the same service, so App
creation has ONE canonical write path (the no-divergence invariant). Update/delete
stay inline for now (their reference-checking is consolidated when the applier gains
update via UpdateRealm). Behaviour-preserving: AppCatalogDeleteBlockTests stays green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… cross-references

Redesigns RealmManifest to key-based cross-references (apps by slug, roles by key,
permissions by resource:action) mirroring demo-seed.json; the applier resolves keys to
ids in dependency order (apps → apis/scopes/clients → roles → users). Adds App and Role
to the applier by reusing the now-shared AppAdminService / RoleAdminService (Role create
extracted from RolesEndpoints; the realm:admin guard is a parameter, true for trusted
control-plane provisioning). APIs/roles resolve their app's permission catalog by
resource:action; clients resolve app links by slug.

RealmManifestApplierTests imports a realm with an app+catalog, an app-linked API/scope,
a confidential client, an app-scoped role (2 permissions), and a user — and verifies
they land in the new realm's tenant DB (isolated from system).

Groups deferred: CreateGroupHandler cascades a durable Wolverine message (membership
recalculation) that InvokeForTenantAsync routes to the tenant DB, which has no Wolverine
durability tables. Recorded in Atlas for a focused follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ession

Completes entity coverage. Groups are created by calling the canonical CreateGroupHandler
directly with the applier scope's plain (TenantedSessionFactory) IDocumentSession — NOT
the Wolverine-outbox-enrolled session that InvokeForTenantAsync would supply. That avoids
the durable-inbox auto-membership event forwarding (ReferenceSyncRegistration routes
GroupCreatedEvent to a UseDurableInbox() local queue) which would otherwise try to write
wolverine_incoming_envelopes in the fresh tenant DB, which has no Wolverine tables. This
mirrors why user creation already worked (ASP.NET Identity's UserManager commits via a
non-enrolled session). The skipped auto-membership sync is fine for provisioning —
membership re-derives at login (LoginTimeMembershipDeriver).

Member/role cross-references resolve by key (user key → id, role key → id). The test
imports a group referencing a manifest user + role and asserts both resolved.

Applier coverage is now complete: realm, settings, apps(+catalog), apis, scopes,
clients, roles, users, groups — all via the canonical operations with all-or-nothing
rollback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…de bodies

Extracts the App/Role update operations into the shared admin services so the
realm-provisioning applier and the admin API share one canonical write path:
- AppAdminService.UpdateAppAsync (ErrorOr<App>) — display name + catalog with the
  catalog-delete reference block; the rich 409 blocker list rides through
  Error.Metadata so AppsEndpoints renders the exact body AppDetails.vue consumes.
- RoleAdminService.UpdateRoleAsync (ErrorOr<PermissionRole>) — the realm:admin
  guard is a parameter (endpoint passes the caller's status, applier passes true).
- FindReferencesAsync + the permission-reference shape move into AppAdminService;
  the App-delete block reuses them. The duplicate endpoint-side NormalizePermissions
  and BuildRoleAsync are removed.

Also fixes two latent regressions the earlier create-extraction introduced: the
App/Role endpoints routed errors through the shared ErrorOrExtensions.ToResult,
which drops the error code from the body and maps Forbidden to Results.Forbid() —
under this app's cookie auth that is an empty-body 403 for /api/*
(OnRedirectToAccessDenied). Both endpoints now render {Error,Message} with the
code in the body, restoring RealmAdminEscalationGuardTests (role create-forbidden
+ app reserved-permission), which were only green because the prior work ran the
provisioning filter, not the security suite.

1269 unit + 459 integration tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…erge)

Adds the full-update half of declarative realm provisioning. UpdateRealmAsync
requires the slug to exist and upserts every manifest entity by natural key (app
slug, api/scope/role/group name, client id, user email/username) through the SAME
canonical Update op the admin API uses, creating it when absent. The realm DB is
never dropped (that would discard signing keys, the OpenIddict token store and
user subs), so this is a strict in-place merge — unlike import there is no
all-or-nothing rollback; a mid-apply failure leaves earlier writes committed and
is safe to re-apply.

Field semantics: booleans are always applied; scalar strings and non-empty lists
replace the stored value; an omitted/empty list and a null app-link leave the
stored value unchanged (sets and changes, never clears or detaches — that stays an
admin-API/Stage-2 op). App-catalog entry ids are preserved by resource:action so
an unchanged permission keeps its id and doesn't trip the catalog-delete block.
Client secrets are minted only at create.

Two tenant-durability gotchas mirrored from import: user UPDATE goes through
UpdateUserHandler on a PLAIN session (not the bus) because UserUpdatedEvent's
durable ReferenceSync forwarding would write wolverine_*_envelopes tables the
tenant DB lacks; groups likewise use plain Create/UpdateGroupHandler. Group
member/role refs fall back to a DB lookup for entities not created this run.

Entity-level prune (removing entities absent from the manifest) is deliberately
out of scope (Stage 2).

RealmManifestApplierTests: import then update a realm that changes every entity and
adds a new role; asserts the updates land, ids stay stable (in-place), and a
missing slug is rejected (Realm.NotFound).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-delete

Exposes the RealmManifestApplier over the existing control-plane-gated
/api/admin/realms group:
- POST /import — provision a brand-new realm from a full manifest (201 + slug,
  primary domain, and the plaintext client secrets available only at create).
- POST /{slug}/apply — in-place merge/upsert against an existing realm; the route
  slug must match the manifest realm slug (400 Manifest.SlugMismatch otherwise).
- DELETE /{slug}?hard=true — escalates the existing soft-delete to the prod-safe
  hard delete that DROPs the tenant DB; default false keeps soft-delete behaviour.

Manifest errors render {Error,Message} with the code in the body
(Realm.AlreadyExists / Realm.NotFound / Manifest.*) so a test-kit can distinguish
outcomes — not collapsed through the shared ToResult.

RealmProvisioningEndpointsTests drives the import->apply->hard-delete round trip
plus the duplicate-409 / missing-404 / slug-mismatch-400 paths against an isolated
cold host. Export (GET /{slug}/export) is deferred — it needs a secrets round-trip
design (hashes can't re-import through the canonical create ops) and isn't on the
test-kit's critical path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…test

Adds a standalone, dependency-light NuGet-able test-kit so consumer-app integration
tests can spin up a real, isolated Modgud realm per test from a declarative manifest:

- ModgudProvisioningClient(HttpClient) wraps the control-plane provisioning API.
  ImportRealmAsync(manifest) returns a ProvisionedRealm handle exposing Authority,
  PrimaryDomain, and the freshly minted client secrets (SecretFor). ApplyAsync does an
  in-place merge; DisposeAsync hard-deletes the realm (drops the tenant DB), so
  `await using` gives automatic teardown. Server error codes (Realm.AlreadyExists,
  Realm.NotFound, Manifest.SlugMismatch) surface as ModgudProvisioningException.Code.
- The kit ships its own manifest POCOs (the client-side mirror of the server contract)
  so it carries zero server dependencies. ProvisioningTestKitTests serialises those
  POCOs against the live import/apply/delete endpoints, so any drift between the kit's
  shape and the server's manifest contract fails there.

RealmManifestParityTests is the drift-guard: it builds the same OAuth client two ways —
via OAuthAdminService.CreateClientAsync with an explicit DTO (the admin-API path) and
via a manifest import — and asserts the projected client shape (type, consent,
redirects, grants, permissions, enabled, app-links-by-slug) is identical. Both go
through the same canonical service, so a mismatch can only mean the applier's
manifest->DTO mapping drifted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the get → edit → "set a password" → apply round-trip the operator workflow
needs, without ever exposing stored credentials.

Export (GET /api/admin/realms/{slug}/export, control-plane realm:read):
- RealmManifestExporter is the inverse of the applier — reads the realm's current state and
  emits a RealmManifest, reversing cross-references back to keys (app slug, role/user key,
  resource:action). STRUCTURE-ONLY: never emits client secrets or password hashes (they're
  one-way; a re-import generates fresh secrets / leaves passwords untouched). Omits entities
  that can't cleanly re-apply: auto-seeded standard OIDC scopes and system apps, plus
  service-account-linked clients (the manifest doesn't model service accounts). Settings are
  not exported yet — re-applying with no Settings leaves them untouched.

Set-password on apply:
- Extracts the admin set/reset-password logic into the canonical SetUserPasswordHandler
  (the PUT /api/user/{id}/password endpoint now delegates to it, preserving 200 + the
  RevokeAllAccessAsync kill-switch). The applier's update path calls it when a manifest
  carries a Password on an EXISTING user — so you can export (passwordless), drop a password
  on a user, and apply to make them able to log in. New users already get theirs at create.

RealmManifestExportTests: import (passwordless user + confidential client) → export →
assert no secret/password/seeded-entity leaks → re-apply unedited (idempotent) → set the
user's password in the export → apply → the user now has a password. Plus an export-endpoint
HTTP test asserting the client secret is omitted from the response.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends RealmManifestExporter to emit manifest.Settings — all nine realm-settings
sections (self-registration, registration-fields, native grants, branding, auth rate
limits, deletion, audit, DCR, CIMD) reverse-mapped from the read shape to the patch shape
at their current values, so an export shows the full config and you can decide what to
change. The write-only captcha secret is intentionally omitted (only a CaptchaSecretSet
flag is readable, never the plaintext); re-applying leaves the stored secret untouched.
Import/apply already consume manifest.Settings, so settings now round-trip end-to-end.

RealmManifestExportTests: asserts Settings is exported (default RegistrationFields value,
captcha secret null), then edits RegistrationFields.Username to Required, applies, and
re-exports to confirm it round-trips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Makes the OAuth-entity (api/scope/client) bool flags nullable in the manifest so a partial
apply only changes what's present: an omitted bool is "no change" on update and the shipped
default on create (Enabled/ShowInDiscoveryDocument default true, the rest false). Previously
a manifest bool always carried its default, so sending a partial entity to change one field
could silently flip e.g. a disabled client back on.

This is the JSON-wire form of Optional<T>: for value types bool? is identical, and the
manifest is bound directly as the HTTP body — matching how the codebase's own partial-update
endpoint (ProfileEndpoints) keeps its wire DTO nullable and uses Optional<T> only for internal
representations (binding Optional<T> from the body would force AddOptionalAware onto the global
resolver). Strings already patch via null=no-change and lists via empty=no-change; only the
always-applied bools needed fixing.

The TestKit's manifest POCO mirrors the nullable bools (wire-compatible). Applier import and
the update-create branch coalesce to defaults; the update path passes the nullable straight
into the already-nullable Update DTOs.

RealmManifestApplierTests: import a disabled client, apply a partial update that changes the
redirect and omits Enabled — the client stays disabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…euse

Stage 2 (prune) prep: the applier must reuse the SAME delete op the admin
API uses, per the single-canonical-write-path invariant. Three delete paths
that lived inline in endpoints are now consolidated:

- AppAdminService.DeleteAppAsync — system-app guard + the App-level reference
  block (roles/RSes linked directly or via the catalog). The rich blocker
  payload rides through Error.Metadata so AppsEndpoints renders the exact
  App.HasReferences 409 body AppDetails.vue consumes (now test-pinned — it
  had zero coverage before).
- RoleAdminService.DeleteRoleAsync — load + PermissionRoleDeletedEvent.
- DeleteGroupCommand/DeleteGroupHandler (Modgud.Authorization.Commands) —
  mirrors create/update; the endpoint invokes it on the bus. The applier
  will construct the handler directly on a PLAIN tenant session (GroupDeleted
  has a durable ReferenceSync forwarder — same tenant-DB-outbox trap as
  create/update groups).

Endpoints delegate and render coded {Error,Message} bodies via their local
ToErrorResult (not the shared ToResult, which drops the code / collapses
Forbidden to an empty 403 — the §7 lesson). Behaviour-preserving.

Tests: App delete-block (unreferenced→204, system→400, role-referenced→409
with the full blocker shape) + role/group delete endpoints (group delete
proves Wolverine discovers DeleteGroupHandler at runtime).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage 2: UpdateRealm gains an opt-in prune. Without it the additive merge is
unchanged; with ?prune=true the apply becomes a full sync (k8s apply --prune) —
after the upsert, every entity in the realm but absent from the manifest is
deleted via its canonical delete op (the ones consolidated in the previous
commit), in reverse-dependency order so a dependent is gone before the app/role
it points at: clients → scopes → apis → groups → users → roles → apps. A still-
referenced app correctly errors.

Lockout + infrastructure protection (the robust superset of "System + last
admin" — protect ALL admins so no manifest can lock the realm out): NEVER
pruned — the system app, auto-seeded standard scopes, SA-linked clients, any
realm-admin role, any user who currently holds realm:admin, and any group that
confers realm:admin (else pruning an admin's group silently strips their admin
path even though the role + user survive). The protection checks run AFTER the
upsert so they see the realm's desired post-merge role graph.

Tenant durability (same trap as create/update): user delete via DeleteUsersHandler
and group delete via DeleteGroupHandler run on the PLAIN tenant session, not the
bus — their events have durable ReferenceSync forwarders that would hit
wolverine_*_envelopes a tenant DB lacks.

Also fixes a real divergence the prune test surfaced: group CREATE now mirrors
the create endpoint's `BoundTo ?? [modgud]` default (CreateGroupHandler itself
defaults null → [] = dormant), so a manifest-provisioned admin group actually
confers its roles instead of silently granting nothing. GroupMembershipGuards
made public so the prune can reuse GroupConfersRealmAdminAsync.

Tests (cold-start): prune removes absent client/scope/api/group/user/role/app
together (reverse-order) while protecting the system app, standard scopes, and
the full realm-admin path (role + admin-conferring group + admin user keeps
realm:admin); ?prune=true endpoint wiring prunes an absent client over HTTP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dev-docs design-of-record for the feature: the endpoint surface, the
single-canonical-write-path invariant, the manifest schema, apply=patch field
semantics (nullable bools / null-string-no-change / empty-list-no-change /
catalog-id preservation / set-password-on-apply), prune (full sync) with the
lockout + infrastructure protection rules, the structure-only export + secrets
stance, and the load-bearing tenant-durability gotchas (TenantContext routing,
plain-vs-bus sessions, the group BoundTo default). Registered in the dev-docs
sidebar + linked from the future-features index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /api/admin/realms/manifest-schema returns the JSON Schema for the import/apply
body, so a consumer (or an agent) can fetch the contract and author a valid manifest
without reading the source.

- Generated from the live RealmManifest type via JsonSchemaExporter using the API's
  own JsonSerializerOptions, so property casing + nullability always match the wire
  contract (can't drift). required: ["Realm"]; the entity lists default to empty.
- Per-field docs ride along: every manifest property/record carries a [Description]
  (keys-not-ids, resource:action, the BoundTo lockout note, nullable-bool patch
  semantics, …) which the exporter copies into each node's `description`. A worked
  example is attached at the root.
- Gated with realm:write on the control-plane app — the SAME permission as
  import/apply, so only a caller who could apply a manifest may fetch its schema
  (not realm:read). Tests: admin gets the described schema + example; an
  unauthenticated caller is denied.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dev/app-testing/ — a self-contained stack (Postgres + the locally-built
modgud:local image, which carries the provisioning feature that isn't on :beta)
on isolated ports, plus a README recipe so another local app's integration tests
can provision a throwaway realm per run via the control-plane API / TestKit and
hard-delete it after. Documents the one-time bootstrap-admin step, the cookie-auth
+ TestKit flow, where to fetch the manifest schema, and the host-routing caveat for
driving real OAuth flows against a provisioned realm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Public/in-app docs (docs/) so humans AND agents discover the feature and learn the
contract:

- New admin/realm-provisioning.md — what it is + why (realm-as-code, per-test realms,
  agent automation), the endpoint table, where to fetch the JSON Schema
  (GET /manifest-schema, realm:write), the manifest at a glance (keys-not-ids,
  resource:action), a curl quickstart, merge-vs-prune, structure-only export, the
  TestKit, and the host-routing caveat for OAuth flows.
- Registered in the docs sidebar (Realm group) + linked from the admin index and the
  Realms page.
- reference/realm-api.md: added the import / apply / apply?prune / export /
  manifest-schema rows and the ?hard=true note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…plane)

A second declarative-config surface for delegating ONE realm to its own admin —
without control-plane powers. New RealmConfigEndpoints at /api/admin/realm-config/*
(apply / export / manifest-schema), reusing the same RealmManifestApplier /
RealmManifestExporter as the control plane; only the entry point + gate differ.

- Runs on the realm's OWN host (not control-plane-filtered), gated by realm:admin in
  the current realm (works for a user OR a service-account token holding realm:admin).
- Scope = TenantContext.Current: the endpoint pins the manifest to the current realm;
  a manifest targeting a different slug is refused (Manifest.SlugMismatch). No import,
  no realm-delete — realm lifecycle stays control-plane-only. apply supports ?prune=true
  bounded to the realm, with the same lockout/infra protections.
- So an operator can: create a realm (control plane), grant a principal realm:admin in
  it, and hand that credential off — it can fully manage that one realm's config +
  entities and nothing else.

Tests (cold-start): apply manages the current realm + export round-trips; a foreign
slug is rejected (400); the surface is gated for an unauthenticated caller. Docs: the
admin guide now contrasts the two surfaces and documents delegation; reference + the
dev-docs note updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a TL;DR at the top of the app-testing README for the common case where an agent
is handed an already-running instance: base URL, control-plane admin creds, and the
5-step login → fetch-schema → import → test → delete flow, with pointers to the
TestKit recipe and the OAuth host-routing caveat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ists

The TL;DR claimed a running instance at a fixed URL + hardcoded creds — that
documents the current machine's state, which is wrong on any other box. Replace it
with a state-neutral "At a glance": (1) get an instance running (build/run/bootstrap,
§1), (2) the per-test flow. The bootstrap step now points at
docs/getting-started/first-time-setup (the canonical recovery-CLI source) and notes
the creds are an example, not a given.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs Dismissed
Comment thread src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestApplier.cs Dismissed
Comment thread src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs Dismissed
@windischb windischb merged commit 7dd13cf into develop Jun 30, 2026
8 checks passed
@windischb windischb deleted the feat/realm-declarative-provisioning branch June 30, 2026 13:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants